Skip to content

chore: promote dev to main for 0.19.0 - #995

Merged
laynepenney merged 37 commits into
mainfrom
promote/v0.19.0
Aug 22, 2026
Merged

chore: promote dev to main for 0.19.0#995
laynepenney merged 37 commits into
mainfrom
promote/v0.19.0

Conversation

@laynepenney

Copy link
Copy Markdown
Member

Ceremony promotion of dev to main for the 0.19.0 release.

Promoted via a throwaway promote/ branch rather than making dev the PR head, because delete_branch_on_merge is enabled and merging a dev-headed PR would delete the integration branch. Ancestry is preserved exactly.

Content is already gated. The release commit passed a six-version public-push gate with two reviewers bound to head d178530; the gate record is on #994. This PR adds no new bytes.

Premium boundary: recall is OSS — memory storage, retrieval, and channel substrate. No identity or org semantics.

laynepenney and others added 30 commits August 7, 2026 12:33
…store

Ref #955 -- closes at promotion.

Channel tests that exercise the post path without isolating resolution fall
through to tier-2 resolution and write fixture messages into the real
home-level channel store. The override to prevent that already existed; what
was missing is that nothing FAILED when a test reached the real store, so the
contamination was silent and cumulative.

Root cause found while building this: four test files "isolate" themselves by
setting SYNAPT_PROJECT_DIR or SYNAPT_DATA_DIR. Neither name is read anywhere
in the package. Setting them produces no error and no warning, so the tests
have looked isolated for their whole lifetime while resolving to the live
store. An isolation step that cannot fail is not an isolation step.

Two layers:

  Layer 1 covers the import window only. pytest_configure runs before any test
  module is imported, so module-level and collection-time code needs somewhere
  safe to resolve; an autouse fixture then hands ordinary resolution back per
  test. That narrowness was learned, not designed -- leaving a session-wide
  SYNAPT_SHARED_CHANNELS_DIR in place broke thirty tests, because the override
  is tier 1 and outranks the tier-3 local resolution some tests deliberately
  exercise, and one shared directory turns independent tests into shared-state
  ones. An environment default is a semantic change to path resolution, not a
  neutral safety net.

  Layer 2 refuses without redirecting, so it does not change where anything
  legitimately resolves. Production gains a no-op policy seam consulted before
  any store directory is created or file opened; with no policy installed
  behaviour is unchanged.

The seam sits at resolution, not only at the write calls: _channels_dir mkdirs
the global directory as part of resolving it, so a guard at the append seam
would report a write rather than prevent one. Five write surfaces are covered,
including one that composes the store path itself and so is reachable by
neither the resolver guard nor the explicit-argument guard.

Also isolates the fourteen tests the guard caught resolving to the live store,
switching them to the override the resolver actually consults.

Measured on one runner, whole suite, BOTH SIDES AT BASE 1245f62:

  base 1245f62, no guard      23 failed / 2768 passed
  this commit, guard armed    23 failed / 2792 passed

Net new failures zero. The +24 passed is exactly the 24 witnesses this commit
adds. The 23 failures are pre-existing at this base and untouched here.

An earlier draft of this message quoted 2774 passed and claimed one incidental
fix. Both came from runs taken at the previous base and did not survive
re-measurement here: the failure that appeared "fixed" was already fixed
upstream. Numbers from two different bases cannot be one delta, and a pair that
does not reconcile is a pair to re-measure rather than to explain.

The data-root half ships behind --strict-recall-data-root, off by default.
Arming it fails 30 further tests across 10 files, and those are true positives:
recall tests deliberately strip the root override to measure path inference,
and that inference currently lands in a real checkout. Isolating those sites is
separate reviewable work; the mechanism ships built and witnessed rather than
absent, with the count reported rather than quietly carried.

Premium boundary: recall is OSS -- this is test infrastructure plus a neutral,
no-op policy seam on local channel and data path resolution. No identity, no
org semantics, no entitlement behaviour.
Review response on the store-isolation guard. Ref #955 -- closes at promotion.

r2 found the seam covered the JSONL surface and not the SQLite one, proven with
a recording policy: channel_post consulted it, the SQLite paths did not. The
finding was right; the two SQLite paths turn out to need opposite treatment, so
each gets the one the evidence assigns it.

_open_state_db IS guarded. It takes its path from the caller, exactly the shape
already recognised for an explicit channels_dir, so resolution never sees it --
and unlike the per-gripspace database it can land inside the global store. A
probe put a caller-supplied path there with the policy consulted zero times
before the file was created. Now refused, with a witness that asserts the file
does not exist afterwards.

_db_path is deliberately NOT guarded by the channel policy, and the docstring
now says so. It is composed from project_data_dir, so it cannot resolve inside
the global store: probed directly, and probed again with the data root forced
at the parent of the protected root, and it stayed outside both times. A
channel-store check there could not refuse anything. Adding one would read as
tightening coverage while installing a check that cannot fail, which is the
defect this seam exists to prevent. Its real coverage is the data-root policy,
which project_data_dir consults -- verified by probe, not assumed. A witness
pins both halves so the asymmetry is not "fixed" later by someone reading the
seam list and seeing an oversight.

Separately, r2 showed importlib.reload silently clears both policies: armed
before, disarmed after, no error. That cannot be fixed where it happens,
because a reload replaces the module wholesale. Patching the two known
mechanisms is the weaker move -- the next one arrives unannounced with the same
signature, a green suite and absent protection. So the policy is re-armed
before every test instead, which closes the class rather than the instances.
Cost is one identity check against None.

Whole suite at base 1245f62: 23 failed / 2795 passed, against a 23 / 2768
baseline. Net new failures zero; the +27 passed is exactly the 27 witnesses.
Guarding the state-db surface surfaced no further true positives.

Premium boundary: recall is OSS -- test infrastructure plus the same neutral,
no-op policy seam. No identity, no org semantics, no entitlement behaviour.
…on-guard

test(recall): fail loudly when a test resolves into the live channel store
…e armed

Ref #967 -- closes at promotion. Phase 1 of the burn-down.

The data-root half of the store-isolation guard shipped behind
--strict-recall-data-root, off by default, because arming it failed 30 tests
across 10 files. Those were true positives: tests/recall deliberately strips
the root override so tests measure path INFERENCE, and that inference resolved
into a real checkout. Every one of the 30 read or wrote the operator's live
.synapt/recall.

The count was re-derived at this base before any edit rather than carried
forward from the issue. It came back 30 with an identical per-file
distribution, but the check was not ceremonial: the original figure was taken
before the guard merged, so it was measured under a configuration that no
longer existed.

Isolation preserves the fixture's intent instead of overriding it. The autouse
fixture strips AMBIENT overrides so these tests measure inference, and that is
correct; its own docstring already carved out the other half -- "unless a test
sets the override itself." So two mechanisms, chosen per site by what the test
is actually asserting:

  A test that NEEDS a store gets an owned root, via a new owned_recall_root
  fixture for pytest-style tests and an owned_store helper for the
  unittest.TestCase classes that cannot take fixtures. One helper rather than a
  hand-rolled save/restore per class, because five copies of an environment
  restore is five chances to restore the wrong thing.

  A test that is genuinely measuring inference keeps measuring it, and moves
  its CWD to a directory it owns instead. recall_save hardcodes
  project = Path.cwd(), so it has no project_dir to pass and the environment
  override does not apply to an explicit path; redirecting the root there would
  have looked like isolation while changing nothing.

One site was a real bug rather than missing isolation: two lines called the
live _reminders_path() and mkdir'd its parent inside the operator's store, then
discarded the value, because the patch immediately below supplies the path
actually used. Dead code that wrote to a real location while reading as setup
for a temporary one.

Whole suite on one runner at this base:

  before, default        23 failed / 2795 passed
  before, strict         53 failed / 2765 passed
  after,  default        23 failed / 2795 passed
  after,  strict         23 failed / 2795 passed

The burn-down set -- strict minus default -- goes from 30 to 0, and the two
modes now produce identical results, which is the property the flip depends on.
Zero new failures against the default baseline. The 23 are pre-existing at this
base and untouched here.

Flipping the default is deliberately NOT in this change. The flag stays off so
this can be reviewed as isolation work on its own evidence; the flip is a
separate one-line change that now has nothing left to hide behind it.

Premium boundary: recall is OSS -- test isolation only. No production code is
touched by this commit. No identity, no org semantics, no entitlement
behaviour.
…ndown

test(recall): isolate the 30 data-root sites so the strict flag can be armed
Ref #967 -- closes at promotion.

The 30 sites that resolved into a real checkout are isolated, so the guard no
longer has anything to break. --strict-recall-data-root is now the default and
--no-strict-recall-data-root is the debugging escape.

THE EVIDENCE FOR THIS FLIP IS NOT THAT BOTH MODES AGREE. Before the burn-down,
running with the flag differed from running without it, and that difference was
the proof the flag was wired. Now they agree -- which is the success criterion
and, identically, the signature of a guard that has stopped working. Modal
agreement can no longer tell those apart, so citing it would be vacuous by
construction.

The evidence is two direct witnesses instead. One asserts the policy is
installed with no flag passed and that an unowned path is still refused. The
other asserts the escape hatch reaches the install decision, in two separately
checkable links -- the option resolving to the destination the installer reads,
and the installer declining to arm when told not to -- because either link
alone is satisfiable without the other.

The escape hatch also has to leave a GREEN suite or nobody will use it, so the
armed-by-default witness skips with a stated reason under
--no-strict-recall-data-root rather than failing. A debugging flag that reddens
the suite is a flag people route around.

Neither witness is a nested pytest run. pytester executes in-process and shares
module globals, which is how this guard silently disarmed itself once already;
a nested run to test disarming would aim that same hazard at the thing it
broke before.

Review also surfaced a third link nobody had pinned: when BOTH flags are
passed, the last one wins. That was already the behaviour, but by argparse
accident rather than by decision -- undocumented, untested, and free to change
under a dependency bump with nothing going red. A CI base config carrying one
flag and a job appending the other would have had its behaviour settled by
argument order nobody wrote down. Last-wins is the conventional answer and a
good one; a witness and one line of option help make it a decision instead of
an accident.

Two further links came from review, both about what the escape hatch is
allowed to mean.

The skip condition discriminates on the ARGV signal rather than the resolved
option. Those agree today and come apart in exactly the case that matters: if
the default is ever silently reverted, the resolved option reads off with no
flag passed, and skipping there would announce "deliberately disarmed" about a
disarm nobody requested. A false reason is worse than no reason, because it
explains away the thing it should surface. It now fails and names the reverted
default.

And the hatch is SCOPED: disarming the data root must leave the channel guard
armed. Nothing witnessed that, so the escape was free to widen into a full
disarm -- taking a guard that was never opt-in -- while every other witness
here still passed, because they arm their own policy or assert against a decoy.
An escape hatch nobody has bounded is a hatch that grows.

Both were mutation-checked rather than assumed: reverting the default makes the
first fail, widening the hatch makes the second fail, and both restore
byte-identical.

The precedence witness runs in a SUBPROCESS rather than through pytester. The hazard that
rules out nested in-process runs here is shared module globals -- a nested run
can disarm the outer guard, and a green result would then be indistinguishable
from the hazard firing. A subprocess shares no globals, so it is the one shape
that can answer an argument-parsing question without risking the thing being
asked about.

Also consolidates test_codex onto the shared owned_store helper. It carried a
hand-rolled two-variable save/restore -- the duplication that helper exists to
remove -- because it was written before the helper existed.

Whole suite: 23 failed / 2798 passed, against a 23 / 2795 baseline. The +3 are
the witnesses this commit adds; the two review links are assertions inside
existing witnesses rather than new ones. Zero new failures. Non-emptiness checked: 2797
is the full suite, not a vacuous green over an empty collection.

Premium boundary: recall is OSS -- test configuration only. No production code
is touched. No identity, no org semantics, no entitlement behaviour.
test(recall): arm the data-root guard by default
Store resolution consulted the gr2 workspace marker before the gr1
membership marker, so a directory carrying both resolved to itself and its
declared membership was never read. Its recall data landed where no other
surface in that workspace looks, and nothing anywhere reported a problem.

The two markers answer different questions. ".grip" asks "am I a workspace
containing units?"; ".gitgrip/griptree.json" asks "whose larger whole am I
a part of?". Store resolution needs the second, because a member's data
belongs to the workspace it is a member of.

Membership is now consulted first, and where it is asserted but cannot be
verified, resolution falls through to the locality marker rather than
reporting nothing. Reordering alone would have made that population WORSE:
such a directory used to short-circuit on ".grip" and resolve to itself,
and would instead have resolved to nothing -- fragmenting a store that
previously cohered, which is the same failure this change exists to
prevent.

Where membership cannot be verified and there is no locality evidence
either, the result is None exactly as before, and the walk deliberately
does not continue upward. Continuing would find a better answer for that
case, and that is a separate change with its own evidence. The property
this one claims is that every directory either improves or is unchanged
and nothing else moves -- which stops being checkable the moment it also
improves an unrelated case.

Five tests, none of which the suite previously had. Every existing case
builds one marker or the other, so all 38 passed identically with and
without the reordering: the suite could not distinguish fixed from unfixed.
The new cases fail with AssertionError against the behaviour they replace,
naming the wrong root. Two of the five are controls rather than claims --
one pins that locality is narrowed and not removed, the other pins the
no-locality case as unchanged and deliberately NOT improved.
fix(recall): a member's store belongs to the workspace it is a member of
A second consecutive `build` over an unchanged store must do ~zero work, run
fast, and say that it skipped. It does not today. Measured on a frozen
4-transcript corpus (39MB, 1,709 chunks) with mtimes pinned so "unchanged" was
a controlled fact rather than an assumption, a run that parsed ZERO files still
took 40.9s. The build has exactly one change-detector, it works, and it guards
the cheapest stage; every expensive stage downstream is unconditional.

Stage attribution on that do-nothing run: parse 1.2s, full FTS5 re-save 1.2s,
full re-cluster 0.6s, re-tag every cluster, timeline rebuild, and then 37.7s
(92%) in the LLM cluster-summary backlog grinder, which loads a model and makes
network round-trips on every build regardless of whether anything changed.

Specifies, as failing tests:

  - parse-skip keys scoped by source dir rather than basename alone. The
    manifest is one flat list spanning every archive dir while the skip dict is
    keyed on basename, so same-named files shadow each other and the loser can
    never match.
  - archive freshness on size OR mtime. Freshness is size-only today, so a
    same-size content edit is never re-archived and therefore never re-indexed.
  - an input signature spanning transcripts, channels and journals, with the
    no-op decision derived from it and surviving a manifest round trip.
  - the summary grinder off the build path and onto an explicit, bounded
    `maintain` command that reports its remaining backlog rather than draining
    it silently.
  - a no-op run that says it is a no-op.
  - incremental as the default with `--full` as the explicit opt-in, and the
    CLI and MCP defaults agreeing (they currently disagree).

Every skip assertion is paired with a negative control that changes exactly one
input and requires the build to notice, so the suite cannot be satisfied by an
implementation that simply skips everything. The controls cover a changed
transcript, a new channel message, a new journal entry, a newly arrived file,
and the skip message itself never appearing on a run that had work to do.

20 failing, 6 passing; the 6 are regression guards and controls that already
hold on current behavior. Failing tests are expected on a feature branch.

Unrelated and pre-existing, recorded so the next reader does not attribute it
here: four tests in test_attribution.py fail on clean dev as well, because
TranscriptChunk reads SYNAPT_AGENT_ID from the environment and the test does
not isolate it. Verified by running them against origin/dev with this file
absent. Not fixed here; out of scope.

Premium boundary: OSS. Index build and retrieval only, no identity or org
semantics.
Declares every fixture in the directory synthetic and names the rules
for adding more, so file names that describe test roles are never again
read as captured data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ance

docs: provenance README for tests/fixtures/identify
…pinned locale

r2's three non-blocking notes from the live-history merge, each with a
born-red witness (the four new tests all red against the pre-hardening
_run_git before this change):

- subprocess.run now carries a 30s ceiling. A live-per-call primitive
  whose git blocks on an index.lock otherwise hangs the caller with no
  way out. Timeout surfaces as the same ValueError contract.
- the repository path stays out of error messages. An error that can
  reach a user-facing surface must not disclose local layout.
- git runs under LC_ALL=C. Parsers and error assertions match git's
  English text, and an unpinned locale is green on this machine and red
  on a non-English one.

tests/recall/test_code_git.py: 18 passed (14 existing untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fix(recall): code_git hardening — timeout ceiling, path-free errors, pinned locale
fix(journal): retain current next steps after session resume
`incremental` gates exactly one call site today, so a build with nothing to
do still walks channels and journals and still pays for the walk. A real
no-op needs a signal covering every input the build reads.

Adds `build_delta`: a signature over archived transcripts, channel logs and
journals, plus `is_noop` and manifest round-trip helpers.

The dangerous direction here is "up to date", not "slow". A signal watching
only transcripts would let a new channel message or a fresh journal entry go
unindexed while the build reports success -- worse than a slow build, because
the operator sees "nothing to do" and the thing they just wrote is missing
from search with no error anywhere. Every input class is therefore in the
signature and each has its own negative-control witness.

Three properties the witnesses pin, each mutation-verified separately:

  - directories are globbed rather than read from a remembered list, so a
    newly arrived file changes the signature;
  - absence is never a no-op: no previous signature, a malformed one, or a
    version mismatch all resolve to doing the work, because the cost of a
    wrong None is one extra build and the cost of a wrong match is a build
    that never happens;
  - entries are sorted before hashing, so the digest depends on the input set
    and not on filesystem iteration order.

Stated limit rather than a discovered one: content is not hashed, so an edit
preserving both size and mtime is invisible to this signal. Hashing every
byte on every build would cost more than the build this exists to skip. The
archive layer refreshes on a newer mtime at equal size for the same reason.

Wiring this into the build path lands in the next change of the series.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…roximation

signature_from_manifest promised in its own docstring to return None for
malformed payloads and did not. It accepted six:

  files=True, files=False   bool subclasses int, so isinstance admits both and
                            the dataclass field declared int held True
  files=-1                  nothing bounded the sign
  digest=<any non-empty>    "x" qualified as a sha256 digest
  version=True, version=1.0 found by closing the class rather than the three
                            reported instances: the guard is a bare != and
                            True == 1 and 1.0 == 1

Closes the class: version and files must be exactly int (type(...) is int, never
isinstance, with the reason commented at the helper), files must be >= 0, and
digest must match a fully anchored 64-lowercase-hex pattern. Any violation
returns None, which is_noop turns into work.

Scope stated honestly rather than dramatically: no WRONG NO-OP was reachable
through these fields. is_noop compares digests, and a malformed digest cannot
equal a real one, so a stale or garbage payload already resolved to work. What
was reachable is a validator certifying "well-formed" over garbage, and a
declared int holding a bool. A module whose entire argument is that
untrustworthy state must resolve to work does not get to carry a gate that
certifies what it did not check.

Fourteen witnesses. Thirteen malformed payloads, each asserting BOTH halves --
the parse refuses AND is_noop turns the refusal into False -- because a guard
returning None while a caller read None as "unchanged" would satisfy the first
assertion and lose the property it exists to protect. The fourteenth is the
acceptance control: thirteen tests asserting None would all pass against a
validator that rejects everything, which fails in the direction that looks safe.

Mutation-verified per guard, not once for the set, because stacked asserts
short-circuit and one mutation would only prove the first. Reverting the files
guard reddens exactly True/False/-1; reverting the digest guard reddens exactly
the six digest shapes; dropping the version guard reddens exactly the two
version cases. The acceptance control stays green under all three.
feat(build): one signal for whether a build has anything to do
`_archive_and_build` called `upgrade_large_cluster_summaries` on every build.
That is unbounded work of a different KIND from everything else in the build
path: the rest is local and scales with what changed, while this makes LLM calls
and ran regardless. It also sat inside a bare `except: pass`, so a build reported
success whether the grinder worked, did nothing, or failed -- the state that
makes a cost invisible.

Its explicit home is the `maintain` subcommand, which lands later in this series.

WHAT IS AND IS NOT LOST MEANWHILE. Summaries come in TWO TIERS, and every loose
sentence about this change has been wrong by collapsing them.

CONCAT is the baseline and is unaffected: the clustering step earlier in the same
function pre-generates a concat summary for EVERY cluster on every build,
skipping only those that already hold an LLM one. No cluster is left without a
summary by this change.

LLM is the upgrade and has two triggers. `process_build_promotions` upgrades by
ACCESS TIER and still runs. The removed pass upgraded by SIZE, independent of
access -- which is why its query looked for clusters where `method = 'llm'` was
absent.

So the residual is exactly this: a cluster that is large but rarely searched
keeps its concat summary and waits for `maintain` to be UPGRADED to LLM quality.
It does not go unsummarized.

Two earlier drafts said "summaries stop being generated" and then "no longer gets
a summary". Same error twice -- writing "summary" unqualified in a system that
has two tiers of them -- and the second was caught in review rather than by me.

Satisfies the merged spec `test_build_never_calls_the_summary_grinder`.
Mutation-verified: restoring the call turns that witness red.

Full suite 11 failed / 2860 passed; every failure is a later-group TDD spec in
the same file (maintain, incremental-default, archive-refresh), down from 12.
Nothing outside the series changed, and nothing asserted on the removed print.
refactor(build): stop running the summary grinder from a build
Two independent staleness defects, both of which made an incremental build
skip a file that had genuinely changed, silently and permanently.

Parse-skip keying. `source_files` is one flat list spanning every build
source, while the reader keyed `already_indexed` on the BASENAME alone. Two
worktrees archiving the same session name produce two entries; under a
basename key the second overwrites the first, so the loser's stamp can never
match its own file and it re-parses on every incremental build, forever. The
key is now (source dir, name), written on both sides -- a reader-only change
would have passed its test and stayed inert in production, because manifests
never carried the dir. Manifests written before the field keep the old flat
key so an upgrade does not force a full rebuild.

Archive freshness. `archive_transcripts` skipped whenever the source and the
archived copy had equal size, treating equal length as equal content. An edit
that preserves byte count is invisible to that check, so it was never
re-archived and therefore never re-indexed -- and the docstring asserted the
behaviour as intended. mtime is now a second, independent signal, compared in
ns so float rounding cannot make an unchanged file look newer than itself.
Both negative controls hold: unchanged files stay a no-op, and a shrunken
source still does not overwrite a longer archive.

Also pins three properties that were correct but unwitnessed, so a later
change cannot weaken them silently:

- a digest with a trailing newline is rejected, which discriminates the `\Z`
  anchor from `$` -- `$` matches before a trailing newline, and the suite
  could not previously tell the two apart
- a zero-file signature is accepted, bounding the guard from the side the
  rejection cases do not reach
- is_noop compares the digest and deliberately not the file count, which is
  derived from the same input rather than independent evidence about it

Each new witness is killed by its own mutation and by no other.
fix(build): scope the parse-skip key by source dir, and refresh on mtime
The archive verbs computed Path.cwd() and passed it as the workspace root.
project_data_dir consults SYNAPT_RECALL_ROOT only when no root is passed, so
forwarding cwd did not merely skip the override, it suppressed it -- the same
env var honored by stats and journal was silently ignored by export and
import, and an export run from a checkout carried the entire live corpus
instead of the workspace the operator named. cmd_import carried the identical
line.

One seam, four callers. archive.py gains _resolve_project_dir, which routes
through project_data_dir(None) so the env override, git-worktree inference,
and gripspace inference apply identically to the archive verbs and to every
other recall surface. It verifies the .synapt/recall suffix before stripping
it, the same defense channel.py already uses, rather than a blind parents[1].
export_recall_archive and import_recall_archive accept None and resolve there.
The CLI and MCP callers stop pretending to know the root. The CLI gains --path
for the case where a caller genuinely does.

Both verbs now report the RESOLVED store they touched, recorded after
inference, never the caller's argument. Export writes data_dir into the
manifest -- the directory the bytes came from -- and prints it. Import
returns data_dir in its summary -- the destination the bytes landed in --
and prints it, while the archive's own origin store travels as
source_data_dir so the two are never conflated: echoing the manifest's
field would name where the bytes came from and read like a destination. A
bare chunk count with no path is exactly the report shape that let a
wrong-store export hide; an argument-derived path would let an
inference-redirected --path hide the same way.

Six witnesses. Export and import against the env override, two roots
each, both mutation-killed by reverting the resolver to cwd. Export
reporting against a real linked worktree with a liveness control,
mutation-killed by recording the argument instead of the resolved dir.
Import reporting from a source root, under an override root, standing in a
third, driving both the CLI print and the MCP text, mutation-killed by
echoing the archive's source store. Absent provenance in both import modes,
against a real pre-provenance archive rewritten from a current one, with the
current archive as the control: the key is omitted rather than None,
mutation-killed by dropping the guard. Same-shell fruit: a configuration
that previously exported the entire live corpus now exports the one planted
entry and names the store it read.

Co-Authored-By: Claude <noreply@anthropic.com>
…all-root

fix(recall): export/import resolve the recall root, never the cwd
…ucket

fix(journal): stabilize worktree bucket resolution
…ain`

Three specs in one seam, all of which needed the parser to be testable first.

PARSER EXTRACTED. `make_parser()` now builds what `main()` used to build
inline. That is not incidental refactoring: while the parser was constructed
inside main(), no test could ask what a bare `synapt build` does, which is how
the CLI came to default to a FULL rebuild while MCP `recall_build` defaulted to
incremental -- the same operation with opposite defaults depending on which
surface you reached through, with nothing red anywhere.
test_cli_and_mcp_defaults_agree now pins the two together.

DEFAULT FLIPPED. Incremental is the default; `--full` is the explicit opt-out
and sets BOTH fields rather than leaving one derived from the other, because
two fields that must agree are two chances to disagree and this pair has
already diverged once. `--incremental` still parses, for scripts that pass it.

A NO-OP RUN SAYS SO -- AND SAYS ONLY WHAT IS TRUE, WITNESSED. A fast build and
a broken build are indistinguishable from outside unless the build states which
stages it skipped, so the short-circuit reports as well as returning early, and
returns a usable index because callers dereference it. The line names parse,
enrich and index, NOT archive: archiving is Step 1 and has already run by the
time the check fires. It copied nothing, which is precisely why the signature
matches, but a stage that executed must not be reported as skipped. An operator
reads that line and stops looking.

The corrected line is WITNESSED. Section F's existing test asserts only that
"up to date" and "skip" appear, so it passes on the truthful line and on the
false one alike -- verified: restoring the false wording reds ONLY the new
test_the_skip_line_does_not_name_a_stage_that_actually_ran while every other
skip-related test stays green. A correction nobody can regress is a correction
that comes back.

THE FINDING, measured rather than reasoned: THE BUILD MUTATES ONE OF ITS OWN
SIGNED INPUTS. It synthesizes auto-journal stubs, so the journal on disk after
a build differs from the journal it read. Storing the pre-build signature
therefore makes the next run compute a different digest from a workspace nobody
touched, and the no-op can NEVER fire -- pre 26636950, post f4d4491d, measured
on an untouched store. So the signature is recomputed after the build rather
than reused from before it.

`_build_journal_files` extracted so the signature and the build read the same
set. Two independent derivations of "the inputs" are two chances to disagree,
and a signature covering fewer files than the build reads yields a FALSE no-op:
the build reports "up to date" while real content sits unindexed, the one
failure direction with no external symptom.

AND THE RECOMPUTE OPENED A SECOND FALSE NO-OP, which took three review rounds
to close properly. Recomputing at the end also picks up ARRIVALS: anything
landing between the read and the manifest write is stat'd into the stored
signature while never having been read, so the next run compares equal, prints
"Up to date", and that content stays invisible until some unrelated change moves
the digest. Three distinct paths, all measured, all found by review: a transcript
dropped after the index save; a journal entry appended after the index save; and
an entry landing after the guards had sampled but before a LATER call computed
the value actually written to the manifest.

The first two fixes were patches on the symptom. Excluding journals from the
guard suppressed a false positive and surrendered every true positive with it,
and adding a per-class journal check left the compared samples and the persisted
sample as different objects -- so the guard verified one value and stored
another, which verifies nothing about what was stored.

WHAT IT NEEDED WAS ONE SAMPLE, TAKEN EARLY, VALIDATED LATE. A single full
signature is computed at the READ BOUNDARY -- stub synthesis complete, journal
parsing not yet begun, the exact line where the build's own writes end and
somebody else's begin -- and THAT OBJECT is what gets persisted. Two comparisons
decide only whether to keep it: read-only inputs must not have moved between the
start of the run and the read boundary (journals excluded from this one alone,
because the build writes them in that window), and the full input set must be
unchanged between the read boundary and the end. Because the stored value is
older than both comparisons, nothing arriving afterwards -- including after the
check itself -- can be inside it. The distinction that matters is a property of
TIME, before-the-read versus after-the-read, not of input class.

SIGN WHAT THE BUILD PARSES, NOT WHAT IT COPIES FROM. Step 1 copies transcripts
into the project archive and every later stage parses THAT archive. The signature
was keyed on source_dirs -- the outside directories copied FROM -- and that is a
different set. The gap is not theoretical: Codex rollouts are copied straight from
the Codex sessions directory and never pass through source_dirs at all, so a Codex
workspace printed "Archived 1 Codex transcript(s)" and "Up to date" in the same run
and never parsed it. The same mis-keying mis-times ordinary transcripts: one written
after Step 1's copy is present in source_dirs and absent from the archive, so it is
signed as parsed without having been, and the next run copies it and then skips. All
four signature computations are now keyed on build_sources. Both paths witnessed,
both red on the prior revision. (Stromus, r1 on v6, offered as one shape closing
both; taken as offered.)

AN INPUT THE BUILD READS MUST BE AN INPUT THE SIGNATURE COVERS. The ChatGPT
export is parsed into the index on every run and was absent from the signature,
so replacing the archive wholesale left the digest unchanged and the next run
skipped it. That is the narrower sibling of the coverage gap this same seam
fixes for journals -- the same class, in the same patch, one input over.

`maintain` is the grinder's explicit home, bounded by default, and REPORTS the
backlog rather than draining it silently -- a queue being worked and a queue
stuck look identical unless the number left is printed. It calls the grinder as
a module attribute, not a from-imported name, so a runtime swap is not silently
ignored.

Evidence: 9 red specs green. The suite is GREEN -- 2929 passed, 25 skipped,
5 xfailed, 0 failed -- and the invocation is stated because the count is not
reproducible without it: from synapt/, `.venv/bin/python -m pytest`, with the
import resolving to this worktree's src/synapt/recall/__init__.py, printed before
the run.

An earlier revision of this message claimed 2978 passing with 5 environmental
failures. That measurement was taken under a pyenv interpreter whose
`synapt.recall` resolved to a DIFFERENT worktree entirely, so it described code
this branch does not contain; the failures were that interpreter's, not this
suite's. The correction is recorded rather than quietly replaced, because a count
that moves without an explanation is indistinguishable from a count that was
never checked.

Mutation table, re-derived against the final shape rather than carried forward.
Persisting the pre-build signature reds the two skip-line witnesses. Forcing the
no-op unconditionally reds 28. Dropping archive coverage reds the archive witness
alone. Dropping the read-boundary-to-end comparison, and persisting a fresh sample
instead of the compared one, each red the persisted-signature witness -- they share
it by construction, being two ways to break one property. Re-keying all four
signature sites back to source_dirs reds the Codex and post-copy witnesses;
re-keying only one of them reds the skip-line witnesses instead, because the
pre-build and persisted samples must agree.

EVERY GUARD IS WITNESSED, AND THE ONE THAT NEARLY WAS NOT IS WORTH NAMING. The
start-to-read-boundary comparison originally guarded mid-parse transcript
arrivals; keying on build_sources subsumed that case and the mutation stopped
reddening anything, leaving it responsible for channels and the archive, which
are parsed before the journal read boundary. My first witness for it resolved
the channels directory by INFERENCE and was defeated by its own inference, which
I misreported as test isolation preventing the witness. It does not: pinning
store resolution through the explicit env seam lands it, and removing the guard
reds that witness and nothing else.

Both arrival witnesses survive every single mutation and go red only when the end
comparison and the persist-the-compared-object property are removed TOGETHER: two
independent mechanisms each covering that case, measured rather than asserted. All
restores verified against a clean baseline.

Co-Authored-By: Claude <noreply@anthropic.com>
laynepenney and others added 7 commits August 19, 2026 11:08
…t-and-maintain

feat(build): incremental by default, a no-op that says so, and `maintain`
…imestamps

fix(journal): normalize timestamp ordering
The Claude session-start prompt opened with "SessionStart:resume hook
success" while performing no resume, and instructed a cron-backed
monitoring loop that is no longer the intended pattern.

The two halves were invisible for different reasons, and only one was
defended. test_startup.py asserted `"CronCreate for the loop" in prompt`
under the docstring "Claude agents keep the existing cron-backed
monitoring instruction" -- so the suite reported agreement with an
instruction that had already been withdrawn. A green test pinning a
retired instruction is worse than no test: it looks like corroboration.

The false label was not defended, it was UNCHECKED: no assertion
referenced it in either direction, so it simply survived. A thing a
passing test does not contradict is not a thing that test defends.

The label now states what the hook DID (startup context loaded) rather
than naming a mechanism that does not run, and the prompt tells the
agent to wait to be prompted and notify its coordinator on completion
instead of polling on a cadence.

Each half is mutation-proven separately, because stacked asserts
short-circuit and one mutation would only have proven the first layer.

Premium boundary: recall is OSS -- this is session-start context
surfacing, no identity or org semantics.
…alse-resume

fix(hook): stop announcing a resume the hook never performs
Version bump, a changelog entry, and one bug fix that belongs in the release
rather than after it.

WHY 0.19.0 AND NOT 0.18.1. 0.18.0 was published to PyPI at 2026-08-07T18:01:21Z.
All 17 first-parent units in the range ca62880..bc594f3 landed after that
instant, checked against each commit's date rather than inferred, so the
published artifact contains none of them. PyPI forbids re-uploading a version.

An earlier version of this message said 11 of those units landed after the
publish date. That was wrong. 11 is a real count, of units carrying product
changes, attached to a claim about timing where the answer is all 17. The range
starts at the 0.18.0 promotion merge, four minutes before PyPI published, so no
subset of it can be the post-publish set. Caught in review before anything shipped.

COUNTING RULE. Over ca62880..bc594f3: 35 commits, 17 merges, 18 direct, 17
first-parent. Of the 17, twelve touch src/ and five touch only tests, docs, or
fixtures; one of those five is a topology-repair merge carrying no content.

THE FIX. tests/recall_store_isolation.py imported the POSIX-only pwd module at
module scope. conftest.py imports that module at root scope, so on Windows the
ModuleNotFoundError fired before any collection at all, taking down the whole
run rather than one module. The import now happens at call time; POSIX
behaviour is byte-identical.

Deliberately not replaced with Path.home(). That reads $HOME, which is exactly
the value a fixture can move, and the protected boundary must not be derivable
from the value under test or the guarantee becomes circular. On Windows it
still raises, at the point of use, which is honest for a POSIX-only guarantee.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@laynepenney
laynepenney merged commit db26f30 into main Aug 22, 2026
9 of 12 checks passed
@laynepenney
laynepenney deleted the promote/v0.19.0 branch August 22, 2026 00:56
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 22, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant